officeparser 7.6.1 → 7.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -780,6 +780,8 @@ idempotent and `.md → AST → HTML → AST → .md` survives unchanged.
780
780
  | Attribute lists | `![alt](img.png){width=50% .centered}` | `ImageMetadata.width` / `.align`, `TableMetadata.align` |
781
781
  | Citations | `[@smith2024]` | `TextMetadata.citationKey` |
782
782
  | Wikilinks | `[[Page]]` / `[[Page\|Alias]]` | `TextMetadata.wikilink`, `.link`, `.linkType` |
783
+ | Highlight | `==text==` | `TextMetadata.backgroundColor` |
784
+ | Link/image titles | `[text](url "Title")` / `![alt](img.png "Title")` | `TextMetadata.title` / `ImageMetadata.title` |
783
785
  | Inline/block math | `$E=mc^2$` / `` $$...$$ `` | `type: 'code'`, `CodeMetadata.math` (`'inline' \| 'block'`) |
784
786
  | Frontmatter arrays | `tags: [a, b]` or `tags: ["a","b"]` | Real array in `metadata.customProperties`/`nativeProperties` |
785
787
  | MDX components (import-only) | `<Component prop="x">...</Component>` | Stripped; inner Markdown is kept. Never generated back. |
@@ -795,7 +797,8 @@ save→reload cycle:
795
797
  | HTML attribute | AST field | Notes |
796
798
  |---|---|---|
797
799
  | `data-width` / `data-align` / inline `style="width:…"` on `<img>` | `ImageMetadata.width` / `.align` | |
798
- | `data-align` on `<table>` | `TableMetadata.align` | |
800
+ | `data-align` on `<table>` | `TableMetadata.align` | Emitted/parsed as per-column GFM markers (`:---`, `:---:`, `---:`); alignment rides `CellMetadata.align` |
801
+ | `title` on `<a>` / `<img>` | `TextMetadata.title` / `ImageMetadata.title` | Survives both directions (`[text](url "Title")` in Markdown) |
799
802
  | `colspan` / `rowspan` on `<td>`/`<th>` | `CellMetadata.colSpan` / `.rowSpan` | Previously dropped on HTML import — merged cells now survive a save→reload cycle |
800
803
  | `<div data-youtube-video="ID">` / `<iframe src="...youtube.com...">` | `type: 'embed'` | |
801
804
  | `<ul data-type="taskList">` / `<li data-checked>` | `ListMetadata.isTask` / `.checked` | |
@@ -1000,7 +1003,7 @@ Options shared by all generator formats. Pass to `OfficeGenerator.generate(ast,
1000
1003
  | Option | Type | Default | Description |
1001
1004
  |--------|------|---------|-------------|
1002
1005
  | `includeFormatting` | `boolean` | `true` | Include bold/italic/colors/sizes in output |
1003
- | `generateIds` | `boolean` | `true` | Add slug-based `id` attributes to headings |
1006
+ | `generateIds` | `boolean` | `true` | Slug-based heading anchors: `id` attributes on HTML headings, and a `{#slug}` suffix on Markdown headings (`# Title {#title}`, kramdown/Pandoc). Set `false` to omit both — useful when the Markdown is rendered by GFM/CommonMark, which show `{#slug}` as literal text. Applies to all generator formats (it is a top-level option, not under `mdConfig`/`htmlConfig`). |
1004
1007
  | `renderMetadata` | `boolean` | `false` | Render title/author as visible header block |
1005
1008
  | `metadataOverrides` | `MetadataOverrides` | `{}` | Override the metadata embedded in the output, merged per field over `ast.metadata` |
1006
1009
  | `includeImages` | `boolean` | `true` | Include image nodes in output |
@@ -1146,7 +1149,8 @@ Pass as `mdConfig` inside `GeneratorConfig`.
1146
1149
 
1147
1150
  | Option | Type | Default | Description |
1148
1151
  |--------|------|---------|-------------|
1149
- | `fallbackToHtml` | `boolean \| FallbackToHtmlConfig` | `true` | Use HTML tags for features Markdown cannot represent (underlines, merged table cells, embeds, etc.). Pass an object for per-feature control. `inlineFormatting` (default `false`, opt-in even when the boolean is `true`) additionally round-trips inline color/highlight/font-size as `<span style="...">` runs. |
1152
+ | `fallbackToHtml` | `boolean \| FallbackToHtmlConfig` | `true` | Use HTML tags for features Markdown cannot represent (underlines, merged table cells, embeds, etc.). Pass an object for per-feature control. `cellLineBreaks`/`itemLineBreaks` (default on) join multi-line table-cell / multi-paragraph list-item content with `<br>` instead of a space. `inlineFormatting` (default `false`, opt-in even when the boolean is `true`) additionally round-trips inline color/highlight/font-size as `<span style="...">` runs. |
1153
+ | `dialect` | `MarkdownDialectPreset \| MarkdownDialectConfig` | `'extended'` | Which native syntax to emit for constructs that differ across targets (GitHub/GitLab/Obsidian/Pandoc/CommonMark). Each capability is typed by the syntax it selects (e.g. `strikethrough: 'tilde'`, `highlight: 'equals'`, `admonitions: 'blockquote'`), with `'none'` to turn it off. See [Markdown Dialect Support](#markdown-dialect-support). The old `boolean` toggles and admonition flavour names (`'github'`/`'gitlab'`/`'pandoc'`) still work but are deprecated. |
1150
1154
 
1151
1155
  ### PdfGeneratorConfig
1152
1156
 
@@ -446,7 +446,11 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
446
446
  */
447
447
  async processNodeArray(nodes) {
448
448
  let html = '';
449
- // Stack to track active lists: { indentation, type, isTask }
449
+ // Stack to track active lists. `liClose` is the currently-open item's deferred closing
450
+ // suffix (`</li>`, or `</div></li>` for a task item): a list item is rendered WITHOUT its
451
+ // close so a deeper list can land inside it (spec-valid `<li>a<ul>...</ul></li>` rather
452
+ // than the invalid `<li>a</li><ul>...</ul>` sibling shape). The close is emitted when a
453
+ // same-level sibling arrives, when the level is popped, or at the end.
450
454
  const listStack = [];
451
455
  const openListTag = (type, isTask) => {
452
456
  if (isTask)
@@ -457,7 +461,7 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
457
461
  const closeListsToLevel = (level) => {
458
462
  while (listStack.length > 0 && listStack[listStack.length - 1].indentation > level) {
459
463
  const list = listStack.pop();
460
- html += closeListTag(list.type) + '\n\n';
464
+ html += list.liClose + closeListTag(list.type) + '\n\n';
461
465
  }
462
466
  };
463
467
  for (const node of nodes) {
@@ -492,20 +496,32 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
492
496
  closeListsToLevel(indentation);
493
497
  // Handle current level
494
498
  if (listStack.length > 0 && listStack[listStack.length - 1].indentation === indentation) {
495
- if (listStack[listStack.length - 1].type !== type || listStack[listStack.length - 1].isTask !== isTask) {
496
- // Type changed at same level
499
+ const top = listStack[listStack.length - 1];
500
+ if (top.type !== type || top.isTask !== isTask) {
501
+ // Kind changed at the same level: close the open item and the old list,
502
+ // then open the replacement list.
497
503
  const last = listStack.pop();
498
- html += closeListTag(last.type) + '\n';
504
+ html += last.liClose + closeListTag(last.type) + '\n';
499
505
  html += openListTag(type, isTask) + '\n';
500
- listStack.push({ indentation, type, isTask });
506
+ listStack.push({ indentation, type, isTask, liClose: '' });
507
+ }
508
+ else {
509
+ // Sibling at the same level: close the previous item before this one opens.
510
+ html += top.liClose;
501
511
  }
502
512
  }
503
513
  else {
504
- // Start a new nested list
514
+ // Deeper level (or the first list): open a nested list INSIDE the currently
515
+ // open item, leaving the parent <li>'s close pending on its stack frame.
505
516
  html += openListTag(type, isTask) + '\n';
506
- listStack.push({ indentation, type, isTask });
517
+ listStack.push({ indentation, type, isTask, liClose: '' });
507
518
  }
508
519
  html += await this.processNodeRecursive(node, this.nodeProcessor.bind(this), override);
520
+ // Defer this item's close so a nested list can land inside it. A string override is
521
+ // a complete replacement item that already carries its own close, so add none.
522
+ listStack[listStack.length - 1].liClose = (typeof override === 'string')
523
+ ? ''
524
+ : (isTask ? '</div></li>' : '</li>');
509
525
  }
510
526
  else {
511
527
  // Non-list node closes all active lists
@@ -712,7 +728,8 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
712
728
  imgStyleParts.push('display: block', `margin-left: ${ml}`, `margin-right: ${mr}`);
713
729
  }
714
730
  const imgStyleAttr = imgStyleParts.length > 0 ? ` style="${imgStyleParts.join('; ')}"` : '';
715
- const img = `<img src="${(0, sanitize_js_1.sanitizeImageUrl)(src)}" alt="${this.escape(node.text || meta?.altText || '')}"${className}${mappedAttrs}${imgDataAttrs}${imgStyleAttr}>`;
731
+ const imgTitle = meta?.title ? ` title="${this.escape(meta.title)}"` : '';
732
+ const img = `<img src="${(0, sanitize_js_1.sanitizeImageUrl)(src)}" alt="${this.escape(node.text || meta?.altText || '')}"${imgTitle}${className}${mappedAttrs}${imgDataAttrs}${imgStyleAttr}>`;
716
733
  const content = this.config.includeFormatting ? `<div class="image-container">${img}<div class="caption">${this.escape(attachmentName || '')}</div></div>` : img;
717
734
  return `${extraAnchors}<div${idAttr}>${content}</div>`;
718
735
  }
@@ -831,7 +848,12 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
831
848
  }
832
849
  const lang = meta?.language ? ` class="language-${this.escape(meta.language)}"` : '';
833
850
  const codeHtml = `<code${lang}>${this.escape(node.text || '')}</code>`;
834
- if (node.text && node.text.includes('\n')) {
851
+ // A `code` node is always block-level (inline code is a monospace text run, emitted
852
+ // as <code> by formatText). Wrap in <pre> whenever it carries a language or spans
853
+ // multiple lines; only a bare single-line, language-less code node stays a <span>.
854
+ // Previously a single-line block (e.g. a one-line ```js) emitted <span><code>, which
855
+ // re-imports as inline code and which strict CodeBlock parsers (only <pre><code>) miss.
856
+ if (meta?.language || (node.text && node.text.includes('\n'))) {
835
857
  return `${extraAnchors}<pre${idAttr}${className}${mappedAttrs}${styleAttr}>${codeHtml}</pre>`;
836
858
  }
837
859
  else {
@@ -839,16 +861,19 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
839
861
  }
840
862
  }
841
863
  case 'list': {
864
+ // The closing suffix (`</div></li>` for a task item, `</li>` otherwise) is emitted
865
+ // by processNodeArray's list stack, not here, so a nested list can be placed inside
866
+ // this item before it closes. See `listStack`/`liClose` there.
842
867
  const meta = node.metadata;
843
868
  if (meta?.isTask) {
844
869
  const checkedAttr = ` data-checked="${meta.checked ? 'true' : 'false'}"`;
845
870
  const checkedBool = meta.checked ? ' checked' : '';
846
- return `${extraAnchors}<li${checkedAttr}${idAttr}${className}${mappedAttrs}${styleAttr}><label><input type="checkbox"${checkedBool}><span></span></label><div>${childrenOutput}</div></li>`;
871
+ return `${extraAnchors}<li${checkedAttr}${idAttr}${className}${mappedAttrs}${styleAttr}><label><input type="checkbox"${checkedBool}><span></span></label><div>${childrenOutput}`;
847
872
  }
848
873
  const value = (meta?.listType === 'ordered' && typeof meta.itemIndex === 'number')
849
874
  ? ` value="${meta.itemIndex + 1}"`
850
875
  : '';
851
- return `${extraAnchors}<li${value}${idAttr}${className}${mappedAttrs}${styleAttr}>${childrenOutput}</li>`;
876
+ return `${extraAnchors}<li${value}${idAttr}${className}${mappedAttrs}${styleAttr}>${childrenOutput}`;
852
877
  }
853
878
  case 'table': {
854
879
  // Smart Table Header Detection
@@ -864,9 +889,13 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
864
889
  const isHeaderStyle = firstRow.metadata?.style?.toLowerCase().includes('header');
865
890
  const allBold = firstRowCells.length > 0 && firstRowCells.every(c => c.children?.every(child => child.formatting?.bold === true));
866
891
  if (isHeaderStyle || allBold) {
867
- // Re-process children with <thead> wrap for the first row
892
+ // Re-process the first row as header cells, wrapped in a <tr>. Without the
893
+ // <tr>, the header cells sit directly under <thead> (`<thead><th>…`), which
894
+ // is invalid HTML that HtmlParser does not read back as a table row - so a
895
+ // md -> HTML -> md round trip lost the header content. `<thead><tr><th>…` is
896
+ // valid and self-idempotent.
868
897
  const headOutput = await this.processNodeRecursive(firstRow, async (n, children) => {
869
- return children.replace(/<td/g, '<th').replace(/<\/td>/g, '</th>');
898
+ return `<tr>${children.replace(/<td/g, '<th').replace(/<\/td>/g, '</th>')}</tr>`;
870
899
  });
871
900
  const bodyRows = rows.slice(1);
872
901
  const bodyOutput = await this.processNodeArray(bodyRows);
@@ -1162,6 +1191,11 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1162
1191
  let result = this.escape(text);
1163
1192
  const f = node.formatting;
1164
1193
  if (this.config.includeFormatting && f) {
1194
+ // Inline code: a monospace run becomes `<code>`, not a `font-family: monospace` span, so
1195
+ // an editor keying on <code> sees it and it re-imports as inline code (HtmlParser maps
1196
+ // <code> back to a monospace run). Innermost, so bold/italic wrap it (`<b><code>…`).
1197
+ if (f.font === 'monospace')
1198
+ result = `<code>${result}</code>`;
1165
1199
  // Inside an `<hN>`, the heading's own styling is authoritative. A run that also carries
1166
1200
  // bold and a font size - the normal case for ODF, where a heading's paragraph style is
1167
1201
  // inherited by its runs - would wrap the text in `<b>` the heading already implies and,
@@ -1216,7 +1250,8 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1216
1250
  else if (meta?.link) {
1217
1251
  const isInternal = meta.linkType !== 'external';
1218
1252
  if (!this.config.ignoreInternalLinks || !isInternal) {
1219
- result = `<a href="${(0, sanitize_js_1.sanitizeUrl)(meta.link)}"${meta.linkType === 'external' ? ' target="_blank"' : ''}>${result}</a>`;
1253
+ const linkTitle = meta.title ? ` title="${this.escape(meta.title)}"` : '';
1254
+ result = `<a href="${(0, sanitize_js_1.sanitizeUrl)(meta.link)}"${linkTitle}${meta.linkType === 'external' ? ' target="_blank"' : ''}>${result}</a>`;
1220
1255
  }
1221
1256
  }
1222
1257
  if (meta?.abbreviationTitle) {
@@ -1246,6 +1281,12 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1246
1281
  const meta = node.metadata;
1247
1282
  if (meta.alignment)
1248
1283
  pushSafe('text-align', meta.alignment);
1284
+ // A table cell's column alignment (GFM `:---`/`:---:`/`---:`) lives on
1285
+ // `CellMetadata.align`, not `alignment`. Emit it as `text-align` on the `<th>`/`<td>`
1286
+ // so `HtmlParser` reads it back and the pipe-table markers survive AST -> HTML -> AST.
1287
+ // An unaligned cell (no `align`) adds nothing, keeping its HTML byte-identical.
1288
+ if (node.type === 'cell' && meta.align)
1289
+ pushSafe('text-align', meta.align);
1249
1290
  if (meta.backgroundColor)
1250
1291
  pushSafe('background-color', meta.backgroundColor);
1251
1292
  if (meta.verticalAlign)
@@ -1270,7 +1311,9 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1270
1311
  pushSafe('background-color', f.backgroundColor);
1271
1312
  if (f.size && !options.skipFontSize)
1272
1313
  pushSafe('font-size', f.size);
1273
- if (f.font) {
1314
+ // A monospace run is emitted as <code> by formatText, so it must not also become a
1315
+ // font-family style here (that was the old, non-semantic inline-code shape).
1316
+ if (f.font && f.font !== 'monospace') {
1274
1317
  const safeFont = (0, sanitize_js_1.sanitizeCssValue)(f.font);
1275
1318
  if (safeFont)
1276
1319
  styles.push(`font-family: ${safeFont}, sans-serif`);
@@ -27,12 +27,12 @@ const foldLines = (value) => String(value ?? '').replace(/[\r\n]+/g, ' ');
27
27
  * exactly (every feature on, GitHub-style admonitions) - the backward-compatibility anchor.
28
28
  */
29
29
  const MARKDOWN_DIALECT_PRESETS = {
30
- extended: { admonitions: 'github', definitionLists: true, footnotes: true, citations: true, wikilinks: true, math: 'dollar', attributeLists: true, strikethrough: true, bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'native' },
31
- github: { admonitions: 'github', definitionLists: false, footnotes: true, citations: false, wikilinks: false, math: 'dollar', attributeLists: false, strikethrough: true, bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'native' },
32
- gitlab: { admonitions: 'gitlab', definitionLists: false, footnotes: true, citations: false, wikilinks: false, math: 'dollar', attributeLists: false, strikethrough: true, bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'native' },
33
- obsidian: { admonitions: 'github', definitionLists: false, footnotes: true, citations: false, wikilinks: true, math: 'dollar', attributeLists: false, strikethrough: true, bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'native' },
34
- pandoc: { admonitions: 'pandoc', definitionLists: true, footnotes: true, citations: true, wikilinks: false, math: 'dollar', attributeLists: true, strikethrough: true, bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'native' },
35
- commonmark: { admonitions: 'none', definitionLists: false, footnotes: false, citations: false, wikilinks: false, math: 'none', attributeLists: false, strikethrough: false, bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'html' },
30
+ extended: { admonitions: 'blockquote', definitionLists: 'colon', footnotes: 'caret', citations: 'at', wikilinks: 'double-bracket', math: 'dollar', attributeLists: 'brace', strikethrough: 'tilde', highlight: 'equals', bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'native' },
31
+ github: { admonitions: 'blockquote', definitionLists: 'none', footnotes: 'caret', citations: 'none', wikilinks: 'none', math: 'dollar', attributeLists: 'none', strikethrough: 'tilde', highlight: 'none', bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'native' },
32
+ gitlab: { admonitions: 'fence', definitionLists: 'none', footnotes: 'caret', citations: 'none', wikilinks: 'none', math: 'dollar', attributeLists: 'none', strikethrough: 'tilde', highlight: 'none', bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'native' },
33
+ obsidian: { admonitions: 'blockquote', definitionLists: 'none', footnotes: 'caret', citations: 'none', wikilinks: 'double-bracket', math: 'dollar', attributeLists: 'none', strikethrough: 'tilde', highlight: 'equals', bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'native' },
34
+ pandoc: { admonitions: 'fence-attribute', definitionLists: 'colon', footnotes: 'caret', citations: 'at', wikilinks: 'none', math: 'dollar', attributeLists: 'brace', strikethrough: 'tilde', highlight: 'none', bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'native' },
35
+ commonmark: { admonitions: 'none', definitionLists: 'none', footnotes: 'none', citations: 'none', wikilinks: 'none', math: 'none', attributeLists: 'none', strikethrough: 'none', highlight: 'none', bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'html' },
36
36
  };
37
37
  /**
38
38
  * Normalizes `MdGeneratorConfig.dialect` into a fully-resolved preset. A string names a preset
@@ -40,6 +40,30 @@ const MARKDOWN_DIALECT_PRESETS = {
40
40
  * omitted field falls back to - NOT "whatever preset was ambient before", since config merging
41
41
  * replaces the whole `dialect` field rather than layering an object on top of a prior string.
42
42
  */
43
+ /**
44
+ * Coerces a per-capability field to its canonical syntax variant. An omitted value inherits `base`;
45
+ * a deprecated boolean maps `true` -> `onValue` and `false` -> `'none'` (the two are the only legacy
46
+ * inputs, dropped next major); an explicit syntax string passes through unchanged.
47
+ */
48
+ function resolveToggle(value, onValue, base) {
49
+ if (value === undefined)
50
+ return base;
51
+ if (value === true)
52
+ return onValue;
53
+ if (value === false)
54
+ return 'none';
55
+ return value;
56
+ }
57
+ /** Maps the deprecated admonition flavor aliases to their syntax names; passes syntax names through. */
58
+ function resolveAdmonitions(value, base) {
59
+ switch (value) {
60
+ case undefined: return base;
61
+ case 'github': return 'blockquote';
62
+ case 'gitlab': return 'fence';
63
+ case 'pandoc': return 'fence-attribute';
64
+ default: return value;
65
+ }
66
+ }
43
67
  function resolveDialect(dialect) {
44
68
  if (dialect === undefined)
45
69
  return MARKDOWN_DIALECT_PRESETS.extended;
@@ -47,14 +71,15 @@ function resolveDialect(dialect) {
47
71
  return MARKDOWN_DIALECT_PRESETS[dialect] ?? MARKDOWN_DIALECT_PRESETS.extended;
48
72
  const base = MARKDOWN_DIALECT_PRESETS[dialect.extends ?? 'extended'] ?? MARKDOWN_DIALECT_PRESETS.extended;
49
73
  return {
50
- admonitions: dialect.admonitions ?? base.admonitions,
51
- definitionLists: dialect.definitionLists ?? base.definitionLists,
52
- footnotes: dialect.footnotes ?? base.footnotes,
53
- citations: dialect.citations ?? base.citations,
54
- wikilinks: dialect.wikilinks ?? base.wikilinks,
74
+ admonitions: resolveAdmonitions(dialect.admonitions, base.admonitions),
75
+ definitionLists: resolveToggle(dialect.definitionLists, 'colon', base.definitionLists),
76
+ footnotes: resolveToggle(dialect.footnotes, 'caret', base.footnotes),
77
+ citations: resolveToggle(dialect.citations, 'at', base.citations),
78
+ wikilinks: resolveToggle(dialect.wikilinks, 'double-bracket', base.wikilinks),
55
79
  math: dialect.math ?? base.math,
56
- attributeLists: dialect.attributeLists ?? base.attributeLists,
57
- strikethrough: dialect.strikethrough ?? base.strikethrough,
80
+ attributeLists: resolveToggle(dialect.attributeLists, 'brace', base.attributeLists),
81
+ strikethrough: resolveToggle(dialect.strikethrough, 'tilde', base.strikethrough),
82
+ highlight: dialect.highlight ?? base.highlight,
58
83
  bulletListMarker: dialect.bulletListMarker ?? base.bulletListMarker,
59
84
  orderedListMarker: dialect.orderedListMarker ?? base.orderedListMarker,
60
85
  emphasisMarker: dialect.emphasisMarker ?? base.emphasisMarker,
@@ -71,6 +96,7 @@ function resolveFallbackToHtml(fallbackToHtml) {
71
96
  // inlineFormatting is opt-in only: it is never enabled by the boolean form, since it changes
72
97
  // default output. Every other field follows the boolean.
73
98
  textFormatting: on, alignment: on, anchors: on, tables: on, embeds: on, cellLineBreaks: on,
99
+ itemLineBreaks: on,
74
100
  inlineFormatting: false,
75
101
  });
76
102
  if (fallbackToHtml === undefined || typeof fallbackToHtml === 'boolean')
@@ -83,6 +109,7 @@ function resolveFallbackToHtml(fallbackToHtml) {
83
109
  tables: fallbackToHtml.tables ?? on.tables,
84
110
  embeds: fallbackToHtml.embeds ?? on.embeds,
85
111
  cellLineBreaks: fallbackToHtml.cellLineBreaks ?? on.cellLineBreaks,
112
+ itemLineBreaks: fallbackToHtml.itemLineBreaks ?? on.itemLineBreaks,
86
113
  inlineFormatting: fallbackToHtml.inlineFormatting ?? on.inlineFormatting,
87
114
  };
88
115
  }
@@ -163,10 +190,13 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
163
190
  * ImageMetadata/TableMetadata's width/align fields - the canonical form is always
164
191
  * `key=value`, matching MarkdownParser's own vocabulary (MARKDOWN_DIALECT.md §15).
165
192
  */
166
- renderAttributeList(meta) {
167
- if (!this.resolvedDialect.attributeLists)
193
+ renderAttributeList(meta, options = {}) {
194
+ if (this.resolvedDialect.attributeLists === 'none')
195
+ return '';
196
+ if (!meta)
168
197
  return '';
169
- if (!meta?.width && !meta?.align)
198
+ const align = options.skipAlign ? undefined : meta.align;
199
+ if (!meta.width && !align)
170
200
  return '';
171
201
  const parts = [];
172
202
  // Allowlist, not escape. These land in `metadata.width`/`align` on reparse, which the
@@ -182,8 +212,8 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
182
212
  if (meta.width && MD_LENGTH_PATTERN.test(String(meta.width).trim())) {
183
213
  parts.push(`width=${String(meta.width).trim()}`);
184
214
  }
185
- if (meta.align && MD_ALIGN_VALUES.has(String(meta.align).trim().toLowerCase())) {
186
- parts.push(`align=${String(meta.align).trim().toLowerCase()}`);
215
+ if (align && MD_ALIGN_VALUES.has(String(align).trim().toLowerCase())) {
216
+ parts.push(`align=${String(align).trim().toLowerCase()}`);
187
217
  }
188
218
  if (parts.length === 0)
189
219
  return '';
@@ -265,13 +295,37 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
265
295
  // HTML tag (e.g. <script>) when the Markdown is rendered to HTML.
266
296
  let text = (0, sanitize_js_1.markdownEscapeText)(node.text || '');
267
297
  if (this.config.includeFormatting && node.formatting) {
298
+ // Inline code: re-wrap the RAW text in backticks. The content is literal
299
+ // inside a code span, so the entity-escaped form above must not show through.
300
+ // The fence is one backtick longer than the longest embedded run so an inner
301
+ // backtick can't close the span early, padded when the content touches a
302
+ // backtick. Done before emphasis so bold/italic wrap the span (`**`code`**`).
303
+ // Previously a monospace text node emitted its bare text, dropping the code.
304
+ if (node.formatting.font === 'monospace') {
305
+ const raw = node.text || '';
306
+ const longestRun = Math.max(0, ...(raw.match(/`+/g) || []).map(s => s.length));
307
+ const fence = '`'.repeat(longestRun + 1);
308
+ const pad = (raw.startsWith('`') || raw.endsWith('`')) ? ' ' : '';
309
+ text = `${fence}${pad}${raw}${pad}${fence}`;
310
+ }
268
311
  const emphasisAsterisk = this.resolvedDialect.emphasisMarker === 'asterisk';
269
312
  if (node.formatting.bold && !this.inImplicitBold)
270
313
  text = emphasisAsterisk ? `**${text}**` : `__${text}__`;
271
314
  if (node.formatting.italic)
272
315
  text = emphasisAsterisk ? `*${text}*` : `_${text}_`;
273
- if (node.formatting.strikethrough && this.resolvedDialect.strikethrough)
316
+ if (node.formatting.strikethrough && this.resolvedDialect.strikethrough !== 'none')
274
317
  text = `~~${text}~~`;
318
+ // `==text==` highlight, in dialects that define it (Obsidian/extended). A plain
319
+ // highlight (the default yellow) always becomes `==text==`; a highlight carrying
320
+ // a SPECIFIC colour stays a background-color <span> when `inlineFormatting` is on,
321
+ // so its exact colour survives. With `inlineFormatting` off (no span to hold it)
322
+ // even a coloured highlight degrades to `==` rather than being dropped. In
323
+ // GFM/CommonMark `==` is literal, so a highlight falls through to the <span> path.
324
+ const isDefaultHighlight = node.formatting.backgroundColor === '#ffff00';
325
+ const emitHighlightMark = !!node.formatting.backgroundColor && this.resolvedDialect.highlight !== 'none'
326
+ && (isDefaultHighlight || !this.resolvedFallbackToHtml.inlineFormatting);
327
+ if (emitHighlightMark)
328
+ text = `==${text}==`;
275
329
  // Use HTML tags for formatting not natively supported by standard Markdown
276
330
  if (this.resolvedFallbackToHtml.textFormatting) {
277
331
  if (node.formatting.underline)
@@ -294,14 +348,18 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
294
348
  styles.push(`${prop}: ${safe}`);
295
349
  };
296
350
  pushStyle('color', node.formatting.color);
297
- pushStyle('background-color', node.formatting.backgroundColor);
351
+ // Skip the background-color only when it was already emitted as `==text==`
352
+ // above; a specific-colour highlight in a highlight dialect still keeps its
353
+ // exact colour here.
354
+ if (!emitHighlightMark)
355
+ pushStyle('background-color', node.formatting.backgroundColor);
298
356
  pushStyle('font-size', node.formatting.size);
299
357
  if (styles.length)
300
358
  text = `<span style="${styles.join('; ')}">${text}</span>`;
301
359
  }
302
360
  }
303
361
  const meta = node.metadata;
304
- if (meta?.wikilink && this.resolvedDialect.wikilinks) {
362
+ if (meta?.wikilink && this.resolvedDialect.wikilinks !== 'none') {
305
363
  // Obsidian syntax: bare page name, or page|alias when the display
306
364
  // text differs from the page name. Strip the `[]|`/newline chars
307
365
  // that would break out of the `[[...]]` wrapper.
@@ -327,8 +385,11 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
327
385
  link = '#' + this.slugify(target);
328
386
  }
329
387
  // Reject javascript:/data: schemes and encode `()`/whitespace so the
330
- // URL can't break out of `](...)` or inject a script link.
331
- text = `[${text}](${(0, sanitize_js_1.sanitizeMarkdownUrl)(link)})`;
388
+ // URL can't break out of `](...)` or inject a script link. An advisory
389
+ // title follows as `"title"` (quotes inside it escaped), matching what
390
+ // the parser reads back.
391
+ const linkTitle = meta.title ? ` "${meta.title.replace(/"/g, '\\"')}"` : '';
392
+ text = `[${text}](${(0, sanitize_js_1.sanitizeMarkdownUrl)(link)}${linkTitle})`;
332
393
  }
333
394
  }
334
395
  if (meta?.abbreviationTitle) {
@@ -345,7 +406,7 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
345
406
  // this branch also replaces `text` wholesale, so a strip that left `<`
346
407
  // behind discarded the escaping applied earlier.
347
408
  const key = String(meta.citationKey).replace(/[^a-zA-Z0-9_:.-]/g, '');
348
- text = this.resolvedDialect.citations ? `[@${key}]` : `[${key}]`;
409
+ text = this.resolvedDialect.citations !== 'none' ? `[@${key}]` : `[${key}]`;
349
410
  }
350
411
  return text;
351
412
  }
@@ -395,7 +456,17 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
395
456
  ? (meta.checked ? `${bullet}[x] ` : `${bullet}[ ] `)
396
457
  : (meta?.listType === 'ordered' ? `${(meta.itemIndex ?? 0) + 1}${this.resolvedDialect.orderedListMarker} ` : bullet);
397
458
  const anchors = this.renderAnchors(meta);
398
- return `${indent}${marker}${anchors}${childrenOutput}\n`;
459
+ // A list item is a single Markdown line. HTML-origin items carry `paragraph`
460
+ // children (e.g. `<li><p>a</p><ul>...`), whose renderer appends `\n\n`; dumped
461
+ // verbatim that produces `- a\n\n\n - a1`, whose blank line splits the list
462
+ // apart and whose indent is then stripped on reparse, flattening the nesting.
463
+ // Collapse the item's internal breaks the same way table cells do (see the
464
+ // `cellLineBreaks` handling in renderMarkdownTable): join with `<br>` when the
465
+ // fallback is on, a space when off. Block children (code fences, tables) inside
466
+ // an item degrade under this join, exactly as they do inside a cell.
467
+ const br = this.resolvedFallbackToHtml.itemLineBreaks ? '<br>' : ' ';
468
+ const content = childrenOutput.trim().replace(/[ \t]*\n+/g, br);
469
+ return `${indent}${marker}${anchors}${content}\n`;
399
470
  }
400
471
  case 'image': {
401
472
  if (!this.config.includeImages)
@@ -414,7 +485,8 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
414
485
  // Strip `[]` from alt (would close the `![...]`) and neutralize the URL scheme.
415
486
  const safeAlt = (0, sanitize_js_1.markdownEscapeText)(alt).replace(/[[\]]/g, '');
416
487
  const safeSrc = (0, sanitize_js_1.sanitizeMarkdownUrl)(src, { allowDataImage: true });
417
- return `${anchors}${anchors ? '\n' : ''}![${safeAlt}](${safeSrc})${this.renderAttributeList(meta)}`;
488
+ const imgTitle = meta?.title ? ` "${meta.title.replace(/"/g, '\\"')}"` : '';
489
+ return `${anchors}${anchors ? '\n' : ''}![${safeAlt}](${safeSrc}${imgTitle})${this.renderAttributeList(meta)}`;
418
490
  }
419
491
  case 'table': {
420
492
  const anchors = this.renderAnchors(node.metadata);
@@ -424,7 +496,7 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
424
496
  // only the plain pipe-table form needs the attribute-list syntax for alignment.
425
497
  const usedHtmlFallback = this.resolvedDialect.tables === 'html' ||
426
498
  (this.resolvedFallbackToHtml.tables && (this.hasNestedTable(node) || this.hasColspanOrRowspan(node)));
427
- const attrList = usedHtmlFallback ? '' : this.renderAttributeList(node.metadata);
499
+ const attrList = usedHtmlFallback ? '' : this.renderAttributeList(node.metadata, { skipAlign: true });
428
500
  if (attrList) {
429
501
  // Must glue directly below the last row with no blank line, or
430
502
  // MarkdownParser's block splitter won't see it as part of the same block.
@@ -478,17 +550,20 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
478
550
  return this.resolvedDialect.math === 'dollar' ? `$${mathInline}$` : mathInline;
479
551
  }
480
552
  const lang = (meta?.language || '').replace(/[\r\n`]+/g, '');
481
- // Block code if it contains a line break, else inline. Testing only for `\n`
482
- // routed a CR-only string to the inline branch, where a renderer that
483
- // normalizes `\r` to a line ending sees a blank line, the span dies, and the
484
- // remainder is exposed as raw Markdown. The fence sizing below is correct and
485
- // needs no change; code content itself is not an HTML context.
486
- if (node.text && /[\r\n]/.test(node.text)) {
553
+ // A `code` node is always block-level: genuinely inline code is a monospace
554
+ // text node, never a `code` node. So emit a fenced block whenever the node
555
+ // carries a language OR spans multiple lines. Previously the decision keyed only
556
+ // off a line break, so a single-line code node with a language - `const x = 1;`
557
+ // tagged `js`, or a one-line `mermaid` diagram - collapsed to an inline span,
558
+ // silently dropping both its language and its block-ness. (Testing `[\r\n]`, not
559
+ // just `\n`, still routes a CR-only body to the fenced branch, where a renderer
560
+ // that normalizes `\r` to a line ending would otherwise kill an inline span.)
561
+ if (lang || (node.text && /[\r\n]/.test(node.text))) {
487
562
  // Fence with one more backtick than the longest run inside the content
488
563
  // so an embedded ``` can't close the block early and inject markup.
489
- const longestRun = Math.max(0, ...(node.text.match(/`+/g) || []).map(s => s.length));
564
+ const longestRun = Math.max(0, ...((node.text || '').match(/`+/g) || []).map(s => s.length));
490
565
  const fence = '`'.repeat(Math.max(3, longestRun + 1));
491
- return `\n${fence}${lang}\n${node.text}\n${fence}\n\n`;
566
+ return `\n${fence}${lang}\n${node.text || ''}\n${fence}\n\n`;
492
567
  }
493
568
  else {
494
569
  const t = node.text || '';
@@ -514,7 +589,7 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
514
589
  case 'note': {
515
590
  const meta = node.metadata;
516
591
  if (meta?.noteType === 'footnote' || meta?.noteType === 'endnote') {
517
- if (!this.resolvedDialect.footnotes) {
592
+ if (this.resolvedDialect.footnotes === 'none') {
518
593
  // Dialect has no footnote syntax - the caller inlines this bare body
519
594
  // as a parenthetical at the reference point instead of collecting it
520
595
  // into an end-of-document "### Notes" section under a [^id] marker.
@@ -570,12 +645,12 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
570
645
  const title = meta?.title ? (0, sanitize_js_1.markdownEscapeText)(foldLines(meta.title)) : '';
571
646
  const body = childrenOutput.trim();
572
647
  switch (this.resolvedDialect.admonitions) {
573
- case 'gitlab':
648
+ case 'fence':
574
649
  // GLFM fenced-div: no dedicated title syntax, so a custom title (if
575
650
  // any) is folded into the body as a bold first line.
576
651
  return `:::${type}\n${title ? `**${title}**\n\n` : ''}${body}\n:::\n\n`;
577
- case 'pandoc':
578
- // Pandoc's own fenced-div-with-class syntax; same title handling as gitlab.
652
+ case 'fence-attribute':
653
+ // Pandoc's own fenced-div-with-class syntax; same title handling as fence.
579
654
  return `::: {.${type}}\n${title ? `**${title}**\n\n` : ''}${body}\n:::\n\n`;
580
655
  case 'none': {
581
656
  // Degrade to a plain bold-labeled blockquote, no special marker.
@@ -583,7 +658,7 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
583
658
  const heading = title || label.charAt(0) + label.slice(1).toLowerCase();
584
659
  return `> **${heading}:**\n${quotedLines}\n\n`;
585
660
  }
586
- case 'github':
661
+ case 'blockquote':
587
662
  default: {
588
663
  // Canonical GitHub blockquote form. No dedicated title syntax either
589
664
  // (matches this library's historical output).
@@ -593,15 +668,15 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
593
668
  }
594
669
  }
595
670
  case 'definitionList':
596
- if (!this.resolvedDialect.definitionLists)
671
+ if (this.resolvedDialect.definitionLists === 'none')
597
672
  return `${childrenOutput}\n`;
598
673
  return `${childrenOutput}\n`;
599
674
  case 'definitionTerm':
600
- if (!this.resolvedDialect.definitionLists)
675
+ if (this.resolvedDialect.definitionLists === 'none')
601
676
  return `**${childrenOutput}**\n\n`;
602
677
  return `${childrenOutput}\n`;
603
678
  case 'definitionDescription':
604
- if (!this.resolvedDialect.definitionLists)
679
+ if (this.resolvedDialect.definitionLists === 'none')
605
680
  return `${childrenOutput}\n\n`;
606
681
  return `: ${childrenOutput}\n`;
607
682
  case 'chart':
@@ -716,7 +791,7 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
716
791
  // end-of-document "### Notes" section, or its content would be duplicated.
717
792
  const isInlinedFootnote = (note) => {
718
793
  const meta = note.metadata;
719
- return (meta?.noteType === 'footnote' || meta?.noteType === 'endnote') && !this.resolvedDialect.footnotes;
794
+ return (meta?.noteType === 'footnote' || meta?.noteType === 'endnote') && this.resolvedDialect.footnotes === 'none';
720
795
  };
721
796
  this.collectNotesFrom(node);
722
797
  let result = await processor(node, childrenOutput);
@@ -832,7 +907,7 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
832
907
  return;
833
908
  const isInlinedFootnote = (note) => {
834
909
  const meta = note.metadata;
835
- return (meta?.noteType === 'footnote' || meta?.noteType === 'endnote') && !this.resolvedDialect.footnotes;
910
+ return (meta?.noteType === 'footnote' || meta?.noteType === 'endnote') && this.resolvedDialect.footnotes === 'none';
836
911
  };
837
912
  this.collectedNotes.push(...node.notes.filter(note => !isInlinedFootnote(note)));
838
913
  }
@@ -876,7 +951,9 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
876
951
  let cellContent = await this.processNodeRecursive(cellNode, processor);
877
952
  // Use <br> fallback only if allowed, otherwise space
878
953
  const br = this.resolvedFallbackToHtml.cellLineBreaks ? '<br>' : ' ';
879
- cellContent = cellContent.trim().replace(/\n+/g, br).replace(/\|/g, '\\|');
954
+ // Consume any trailing spaces before the newline(s) too, so a hard-break's
955
+ // ` \n` collapses to a single `<br>` instead of leaving ` <br>` in the cell.
956
+ cellContent = cellContent.trim().replace(/[ \t]*\n+/g, br).replace(/\|/g, '\\|');
880
957
  rowCells.push(cellContent);
881
958
  // Handle colspan by adding empty cells
882
959
  const colSpan = cellNode.metadata?.colSpan || 1;
@@ -893,7 +970,17 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
893
970
  this.inImplicitBold = wasInImplicitBold;
894
971
  }
895
972
  }
896
- // Second pass: Build table string with separator
973
+ // Second pass: Build table string with separator. The separator carries standard GFM
974
+ // per-column alignment (`:---`/`:---:`/`---:`) from columnAlignments, or the single table-level
975
+ // align applied to every column, rather than a non-standard trailing `{align}` attribute list.
976
+ const tableMeta = node.metadata;
977
+ // Column alignment lives on each cell (CellMetadata.align); read it off the header row.
978
+ // Fall back to the single table-level align (an editor's data-align) for every column.
979
+ const headerCells = (node.children?.[0]?.children || []).filter(c => c.type === 'cell');
980
+ const alignMarker = (i) => {
981
+ const a = headerCells[i]?.metadata?.align ?? tableMeta?.align;
982
+ return a === 'center' ? ':---:' : a === 'left' ? ':---' : a === 'right' ? '---:' : '---';
983
+ };
897
984
  for (let i = 0; i < processedRows.length; i++) {
898
985
  const row = processedRows[i];
899
986
  // Pad row with empty cells if it has fewer than maxCols
@@ -902,7 +989,7 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
902
989
  tableOutput += `| ${row.join(' | ')} |\n`;
903
990
  if (i === 0) {
904
991
  // Header separator
905
- tableOutput += `| ${Array(maxCols).fill(' --- ').join(' | ')} |\n`;
992
+ tableOutput += `| ${Array.from({ length: maxCols }, (_, i) => alignMarker(i)).join(' | ')} |\n`;
906
993
  }
907
994
  }
908
995
  return `\n${tableOutput}\n`;